Skip to content

feat(file-safety): guard machinery in the writer, with the identity and rights refusals (U5-B, split 1/2 of #1914, #1375) - #2357

Open
easonLiangWorldedtech wants to merge 80 commits into
Zoo-Code-Org:mainfrom
easonLiangWorldedtech:vps2f-1914-u5b
Open

easonLiangWorldedtech wants to merge 80 commits into
Zoo-Code-Org:mainfrom
easonLiangWorldedtech:vps2f-1914-u5b

Conversation

@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor

Split unit 1/2 of #1914. The plan is on #1991; this branch's parent is PR #1913's head 416179d17de8, so merge #1913 first and this branch adds exactly its own delta on top (measured: 191 changed executable lines under scripts/stryker-diff.mjs rules at pinned node v22.23.4; the cumulative figure a base-main PR prints is 638, because the gate measures against merge-base with the PR base and #1913 is not in main yet).

Contents. src/services/file-safety/safeWriteText.ts + spec, src/utils/fileLock.ts + spec, src/core/task/Task.ts + spec, and one knip.json line: 7 files, 1637 added+deleted lines. guardedWrite.ts and its spec are in unit 2, because the tool consumes this unit's guard API rather than the reverse.

Two refusals are here because the refresh merge made the two copies of safeWriteText.ts diverge, and the divergence had no consumer-side signal. target itself and DaclInspectionError each match 0 lines tree-wide at 33e9200900bb, so under guard-core's shape their absence would have read as agreement.

  1. Staging-path identity: a caller-supplied staging file is refused when it is the target itself by (dev, ino) read with bigint stats (file identities on NTFS/ReFS exceed Number.MAX_SAFE_INTEGER); only ENOENT may skip the comparison - EACCES/ELOOP/ENOTDIR mean the comparison could not be made and is itself a refusal. Without it an alias reaches the commit and the failure handler unlinks the only remaining copy of the content. Recorded as a requirement on safeWriteText's staging-name guard and jsonFileRecovery's leftover prefixes are mutually exclusive #2350.
  2. Access rights on win32: a target that exists but whose DACL cannot be captured (icacls /save fails, including after writing a partial dump - the dump is still unlinked) or whose access rights cannot be read yields DaclInspectionError with phase and targetPath before anything commits, rather than a warning beside a committed file.

Tests moved with the contract, including two that asserted the opposite. Replaced: win32 DACL failure falls back to plain rename (never fails the write) and the partial-dump test's assertion that the write commits. Added: the failed-save refusal, the inspection refusal, the alias refusal, and the non-ENOENT refusal. safeWriteText.spec.ts is 74/74; the inspection test injects platform: "win32" and stubs icacls, so it runs on either hosted leg and the hosted windows leg passes this file.

The knip.json line, and exactly what it hides. knip --production does not credit imports from files knip itself reports as unused, and guardedWrite.ts has no production importer (3 non-test matches: the definition plus two doc mentions), so the guard primitive arrives one unit before its consumer. Without the line this unit fails knip --production with exactly one finding - Unused exports (1): safeWriteText function src/services/file-safety/safeWriteText.ts:439:23 - and plain knip passes either way. With the line, both modes and the 8 knip-policy tests pass. It is not a blanket: unit 2 replaces this entry with core/tools/guardedWrite.ts! rather than adding to it, and unit 2 without its own line reports two findings (Unused files (1): src/core/tools/guardedWrite.ts plus the same unused export), so the entries cover named findings and do not accumulate. This matters because knip is one of the six required contexts on main (check-translations, both platform-unit-test legs, compile, knip, e2e-mock), while the 500-line mutation gate is advisory.

Local gates on this head: prettier, eslint --max-warnings=0, tsc -p src/tsconfig.json --noEmit, knip, knip --production, knip-policy 8/8 - all clean. Vitest: misc 120 files / 2125 tests passed; core 197 files / 4282 passed (one file fewer than the merge head, guardedWrite.spec.ts being unit 2's); services 82 files / 1686 passed with the one pre-existing environmental red (services/rules, 5 failures, all EPERM: operation not permitted, symlink, a file this unit does not touch).

What this push is also measuring. 74f5eb287d1b failed the hosted windows leg on pnpm run test:misc, 3 failures in utils/__tests__/safeWriteJson.test.ts > safeWriteJson > should preserve backups (expected … [{ committed: true }, {}], received [{ stale: true }, {}]; and promise resolved "undefined" instead of rejecting). safeWriteJson.ts and its test are byte-identical between 416179d17de8 (windows-green) and 74f5eb287d1b (windows-red). This head carries the writer and lock changes but none of guardedWrite.ts, so the windows result here tells us which layer flips the recovery path; the read is being reported to the lead either way, and nobody is repairing the recovery semantics until that read lands.

easonLiangWorldedtech added 30 commits October 14, 2026 11:51
…ve (U1, issue 1375)

Split unit U1 of PR 1833. Three changes, each with a test that fails without it:
- a caller-supplied staging path is checked for location and file type before anything is written, so an arbitrary path or a symlink cannot be published onto the target;
- a failed parent-directory fsync on POSIX is reported as PostCommitDurabilityError instead of being swallowed, so a successful return never claims durability the filesystem did not grant;
- the staged file and this write's own staging directory are released before RollbackFailureError is thrown.

Focused coverage for resolveLockKey added: canonical parent directory, a dangling-link chain, and termination at the bounded depth on a two-link cycle.
…ishTarget (U1, issue 1375)

The resolver may fall back to the given path only when lstat also reports the path as absent. An EACCES or EIO failure says nothing about whether the path is a link, so falling back would publish through a link we were not allowed to inspect. Focused tests added for both branches.
… type-sound

compile failed at the unit head on three points:
- RollbackFailureError needs a string backupPath, but the throw now happens after cleanup, so the
  `string | null` narrowing was lost. The failure is now held as { error, backupPath }.
- The async lstat stand-in is built on the Stats prototype so it satisfies fsSync.Stats.
- The realpath/readlink mocks are typed to the real signatures; the readlink mock answers once
  because only the link path is read.

tsc clean, 50 tests pass, ESLint --max-warnings=0 clean, no suppression change.
The any usage this entry covered is gone in the rewritten file, so the count drops 4 -> 3.
eslint --prune-suppressions --max-warnings=0 confirms it.
The read tools record the observed on-disk version through task.observationRegistry, but the field
was only declared in a later unit, so at this head the call dereferences undefined and the mocked
e2e run fails on the read_file smoke tests. The registry is introduced by this unit, so the field
belongs here.

tsc clean on this unit, 11 observationRegistry tests pass, ESLint --max-warnings=0 clean.
…rtion

This unit was rebuilt from the pre-fix content source, so its copy of safeWriteText.ts
still restored the backup over a write whose commit rename had already succeeded when
the parent-directory fsync failed, and its spec asserted rmdir generically rather than
against the staging directory this write created.

Both are already settled in unit 1 (fws/u1-atomic-publish). Taking those files here
keeps the shared code byte-identical across the units, so merging the chain in order
does not overwrite unit 1's fix.

Tests: 52 passed in safeWriteText.spec.ts.
…s notice

The template literal continued on an indented source line, so the model-facing
output carried four leading tabs before the content. Replace the indented newline
with an explicit escape so the notice and the content are separated by a single
newline and nothing else.

Local note: readFileTool.spec.ts cannot run in this worktree (isbinaryfile is not
resolvable from either node_modules here); CI covers it.
safeWriteJson rethrows RollbackFailureError and the telemetry callers record only
error.message, so the generic wrapper message lost the filesystem errno text of the
publish failure. Include the publish error message while keeping the rollback context
(publishError, rollbackError, backupPath) unchanged.

Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0.
… rollback failure

The assertion accepted any rmdir argument, so a regression that removed a different
directory still passed. Read this write's own staging directory from fsSync.mkdirSync
and assert that exact path.

Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0.
The dump path was the fixed sibling <target>.acl.tmp. A pre-existing user file at that
path is unlinked by the failed-save branch and by both cleanup paths, and two concurrent
writes to the same target share one dump, so one write can restore or delete the other's.
Use the per-write unique name like the staging file.

Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0.
…eturned

A slice that starts after line 1 and reaches EOF is not truncated, so the branch said
"The file was read in full" even though the earlier lines were omitted. Name the slice
start when the read did not begin at line 1, and add a focused test for that case.

Local note: readFileTool.spec.ts cannot run in this worktree (isbinaryfile is not
resolvable from either node_modules here) and ESLint cannot resolve its config here;
CI covers both.
…opy backup)

Pre-merge review flagged the publication path as non-atomic when backup:true: it renamed the existing target to a
backup name and only then renamed the staged file into place, so the canonical path was absent for the whole commit
window - readers saw a missing file, and a concurrent writer could create a new target that a later rollback would
destroy.

The backup is now a copy, never a move:
- the destination is created with openSync(backupPath, "wx", 0o600) before any content exists at it, so no content
  ever sits at a path whose mode fs.copyFile chose (it uses the platform creation mask subject to umask on some
  platforms, the source's mode and read-only attribute on others);
- chmod 0o600 after the copy clears a copied read-only attribute (Windows) that would otherwise make the fsync open
  fail with EACCES, and keeps a backup of a permissive file private;
- the copy is fsynced, then deleted once the commit rename has published; nothing is ever renamed back.

safeWriteText also rejects a staging file that is the target itself (same inode/device), which would otherwise let
a caller-supplied tempPath silently alias the target.

Tests: the safeWriteText spec is replaced with the copy-model spec plus a new integration spec that runs against the
real filesystem (no fs mocks) - success publishes and leaves no residue, and a directory target is rejected with the
payload bytes unchanged. The safeWriteJson spec moves from the three-rename/rollback model to the copy model, and the
lock-key spec accounts for the extra lstat the aliasing guard performs.

83 passed / 1 skipped across safeWriteText.spec + integration spec + safeWriteJson + safeWriteJson.lockKey; tsc and
eslint clean, no suppression drift.
Pre-merge review flagged that the write path trusts an unvalidated symlink target: safeWriteText resolves an
existing link to its referent and safeWriteJson then merges and publishes onto that referent, so a repository that
plants its project settings file as a link elsewhere receives the write at the linked path.

safeWriteJson now accepts confineTo. The check runs on the resolved publish target, after resolvePublishTarget and
before the merge read and before anything is staged, so a rejected write takes no lock and leaves no residue. Both
sides are canonicalized the same way: the scope is resolved through symlinks (walking to the nearest existing
ancestor when it does not exist yet, and rethrowing anything other than ENOENT), and the target is canonicalized
with the same helper because a target that does not exist yet still carries the alias components of the path it was
given.

Callers that picked a path from a known scope pass that scope; a caller that does not declare one keeps the current
behaviour. The MCP settings wiring that uses this is on the async-save series (Zoo-Code-Org#1403).

Tests: an out-of-scope path is rejected on every platform with no lock and no staged file; a planted link escaping
the project is rejected and the linked file keeps its bytes; a link inside the project still publishes to the
referent; and a scope declared through a symlinked directory that does not exist yet is accepted.
The staging-identity guard compared the caller-staged file with the target through fs.lstat(targetPath).catch(() =>
null). Any error other than ENOENT - EACCES, ENOTDIR, ELOOP - was therefore indistinguishable from "there is no
target", so a staging path that is a hard link of the target would pass the identity check, reach the commit rename,
and let the failure handler unlink the only copy of the content.

Only ENOENT now yields "no target"; anything else fails closed with a StagingPathError before anything is opened,
staged or renamed. Test: the staging path resolves to a hard link of the target (same ino/dev) and the target lstat
rejects with EACCES; the write is rejected with the comparison error and neither openSync nor rename is called.
The staging-identity guard read fs.Stats.ino/dev as JS numbers. On NTFS and ReFS those identifiers can exceed
Number.MAX_SAFE_INTEGER, and the rounding makes two different files look identical - a valid caller-staged file is
then rejected with StagingPathError (and a real alias could be missed). Both lstats now request { bigint: true } and
the comparison checks for bigint values before comparing them.

The spec stand-in carries bigint ino/dev, matching what lstat({ bigint: true }) returns at runtime (fs.BigIntStats is
a type-only export, so the double is documented at its single assertion).
The identity stand-in returns bigint identifiers regardless of the options, so the same-file tests could still pass if
either lstat dropped { bigint: true } - which is exactly the case that matters on NTFS/ReFS. Both tests now assert
that every identity lstat requested bigint stats.

The calls are filtered by their options rather than by path spelling: path.resolve prefixes a drive letter to
/tmp/... on Windows, so a path filter would only see one of the two reads on that platform.
…served

On win32 the DACL of an existing target is saved before the commit rename so it can be reapplied afterwards. Two
paths silently skipped that step and still published:
- icacls /save failed (saved === false): the dump is cleaned up and the rename proceeds, so the new file inherits
  different access rights;
- fs.access(targetPath) failed with something other than ENOENT (EACCES, ...): the catch treated "cannot check" as
  "target absent" and skipped DACL handling entirely.

Both now report through a new onWarning sink (default console.warn): the write still proceeds - a missing or failing
icacls must not leave the user unable to save, which is the documented fallback - but the caller is told the
replacement may inherit different access rights instead of discovering it later.

Tests: icacls save failure still commits the write and yields exactly one access-rights warning; an EACCES on the
target yields the could-not-check warning and no icacls call. Verified as real regression tests - neutralizing the two
warn calls makes both fail. 272 passed / 4 skipped locally; tsc and eslint clean.
onWarning is documented as the sink for non-fatal safety notices, but delivery was not isolated from the write: an
onWarning callback that threw propagated to the outer failure handler before fs.rename, turning a non-fatal notice into a
failed save. The warn binding now catches callback failures and logs them. The restore-failure notice is routed through
the same binding so a caller supplying onWarning receives it. Same fix as the u6 unit, kept aligned across the series.

Local: safeWriteText 58/58 green; eslint clean, no suppression growth.
proper-lockfile creates ${lockKey}.lock beside the lock key, and the key is the
symlink referent. A repository that plants .roo/mcp.json -> ~/.ssh/config
therefore made a confined write create a lock directory OUTSIDE the declared
scope, and when that directory was not writable the caller got a
lock-acquisition error after up to five retries instead of
ConfinedPathEscapeError. The confinement check now runs on the lock key before
acquireFileLock and is repeated on the resolved publish target inside the lock
(a peer writer may have moved the referent in between); both checks share
_assertWithinScope so they canonicalize identically.

Ported across the file-safety unit series so every unit that declares a scope
carries the same guarantee (same fix as fws/u3-observation-completeness).
Series alignment for the two review findings fixed on fws/u6-apply-patch-wiring:

1. safeWriteText's warn wrapper could not catch a rejection from an async
   onWarning sink - TypeScript accepts a value-returning callback where a void one
   is expected - so the rejected promise was left unhandled, which under Node's
   default mode can end the process after a write that already succeeded. The
   wrapper now attaches a catch handler without awaiting (awaiting would let
   warning delivery delay a committed write, or stall it on a hung sink) and
   reports the rejection through the fallback sink.

2. safeWriteJson created the target's parent directory BEFORE the preflight
   confinement check, so a confined write to an out-of-scope path with a missing
   parent still created a directory outside confineTo. resolveLockKey and the check
   need no directory to exist, so the order is now lock key, confinement, mkdir;
   the in-lock check on the resolved publish target stays.
…ith doUnmock

- safeWriteText.integration.spec: the backup:true case bails out in the backup step (copyfile
  over a directory), so the commit rename never runs and a regression in commit-failure cleanup
  would pass. Added the backup:false case, which is the shape that reaches Step 4: the rename of
  the staging file over the target directory fails and the staging file must be cleaned up.
- safeWriteJson.test: the finally blocks removed a vi.doMock registration with vi.unmock, which
  vitest hoists to the top of the file, so the cleanup did not undo the registration where it
  was written. Both sites now use vi.doUnmock, the non-hoisted counterpart.

Verified the two paths are distinct by calling safeWriteText against a real directory target:
backup:true fails at copyfile, backup:false fails at rename of the staging file.
Local: 29 passed / 4 skipped across the two specs; eslint clean on both files.
Task.ts initializes the registry per Task, but nothing read it off a real Task: the registry
tests exercise standalone instances and ReadFileTool's spec injects its own double, so the
wiring itself was unverified - a Task that never built a registry, or shared one across
Tasks, would pass every existing test while silently dropping read observations.

The new test constructs a real Task and asserts observationRegistry is an ObservationRegistry,
that a second Task gets a different instance, and that an observation recorded on one Task is
invisible to the other (parent and subtask authority over a file must stay separate).

Local: Task.dispose.test.ts 15 passed; eslint clean; no new tsc diagnostics in this file.
Security Boundaries: safeWriteJson resolves the publish target through realpath
before staging beside it, so a repository that ships .roo/mcp.json as a symlink to
a file OUTSIDE the workspace has that outside file replaced as soon as the user
edits a project MCP setting, deletes a server, or toggles tool always-allow. The
three project-capable write sites passed no confinement option, and the base
implementation renamed the link itself, so the external write is introduced here.

confineForMcpWrite(source) returns the provider cwd (falling back to
getWorkspacePath) for project writes and undefined for global ones - global
settings live in the user's own settings directory, which is deliberately not
confined. All three sites now pass it, so the write fails closed with
ConfinedPathEscapeError instead of touching the referent. This is the same change
as 256091d on fws/u3-observation-completeness (Zoo-Code-Org#1912); the unit branches are not
cumulative, so every branch that carries the writer needs its own copy.

Regression Evidence: the per-Task registry now fails closed on teardown too -
disposeOnce() clears it, so a disposed Task still reachable through a
parent/subtask reference cannot hand a token captured before teardown to a
guarded write. Two McpHub tests pin the call sites (a project-scoped allowlist
write carries confineTo = workspace root, a global one does not) and a
Task.dispose test pins the clear. The two-real-Task registry test added in
87986e9 already covers the distinctness the checklist row asked for.

Local: vitest McpHub + Task.dispose 89 passed. Negative controls: helper mutated
to return undefined -> project confine test fails; clear() commented out ->
disposal test fails; both restore green. tsc --noEmit 50 errors before and after
the change (unchanged branch baseline). eslint 0 errors / 0 warnings on all four
files, no suppression entry added or increased.
…every MCP confine site

Lifecycle Resource Cleanup: disposeOnce() cleared the registry, but a read that was
already awaiting fs.readFile when disposal began resumed afterwards and recorded the
file again - the disposed Task was authoritative once more, and it kept doing the new
post-read stat work for a turn that will never be acted on. ObservationRegistry.close()
now drops the entries AND refuses later observations, Task.disposeOnce() calls close(),
and both ReadFileTool paths skip the post-read stat + observe work when task.abort is
set (checked before the awaited stat, not after it).

Regression Evidence: the confinement fix touched three call sites but only
toggleToolAlwaysAllow was pinned. updateServerTimeout and deleteServer now have a
project case (confineTo = workspace root) and a global case (confineTo undefined) each;
the connection double is shared at describe scope so all four sites use one typed
fixture. safeWriteJson is mocked in this file, so a lower-level writer test cannot see
a missing MCP argument - each call site has to be pinned here.

Tests: 2 closure tests (close drops entries and reports closed; observe is a no-op after
close), 2 aborted-task tests (native and legacy path record nothing), 4 call-site
confinement tests, and the disposal test extended to assert the registry stays empty
when a late observation arrives.

Local: 203 passed across readFileTool, McpHub, Task.dispose and observationRegistry.
Negative controls, each mutate -> test fails -> restore -> passes: ReadFileTool guard
neutered, ObservationRegistry closure guard neutered (fails both the registry spec and
the Task disposal test), and close() downgraded to clear() (fails the disposal test).
tsc --noEmit 50 errors before and after (unchanged branch baseline, 0 in touched files).
eslint 0 errors / 0 warnings across all 8 touched files; no suppression entry changed.
The abort guard sat before the post-read fs.stat, so a cancellation that
lands while that stat is in flight still recorded an observation - for a
run that will never act on the read, and into a registry that disposal has
already retired. Both paths now re-check task.abort after the await and
before observe(); the read itself still answers the model.

Two tests flip task.abort inside the pending post-read stat, one per path,
and assert observe() was never called and the registry stayed empty.
@github-actions github-actions Bot added awaiting-author PR is waiting for the author to address requested changes and removed awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Nov 9, 2026
@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Superseding width note for head d6fa7117e07b70455da24e4722b47eb0b202fa6e — this comment replaces 6717760858, 6718039256, 6718343475, 6718664032 and 6721521485. Those five each quoted the scope line for an earlier head (the newest, 6721521485, speaks for 25a1ad65f and quotes 716 lines); they are not wrong about their own heads, but nothing on this PR said which number is current. This one is, for d6fa7117e07b70455da24e4722b47eb0b202fa6e. The superseded ids are named so the five drifting asks read as one ask that moved.

The arm's line, verbatim, from the gate's own buildManifest at this head (scripts/stryker-diff.mjs, the sentence mutation-diff prints; check-run 129040258854 on this head is completed/failure, and its output.title is null with an empty summary, so the sentence is not in the check output — the annotations listing for that check id returns 0 entries even though the run reports 2 annotations, which is why the sentence is quoted from the function that produces it):

Mutation gate failed: extension has 770 changed executable lines (limit 500). Split the PR or obtain a maintainer-reviewed narrow exclusion.

Beside the counted lines, the shape a reviewer needs with it: 26 files, +6,995 −76 (GitHub's own numbers for this head), of which 17 delta entries are __tests__ paths that packageForPath returns undefined for and therefore contribute nothing. The 770 is measured from this PR's own merge base 5319fe91fb0caf4aa4ec66aac68fa03744ebbc23, not from the current upstream/main tip. The partition, from the same functions: safeWriteText.ts 448, safeWriteJson.ts 131, fileLock.ts 62, ReadFileTool.ts 40, McpHub.ts 27, jsonFileRecovery.ts 26, observationRegistry.ts 20, indentation-reader.ts 11, Task.ts 5.

That is the width the split plan on #2350 partitions, unit by unit, against each unit's own merge base: six units at 448 / 88 / 131 / 27 / 25 / 51 counted executable lines, summing to exactly 770. Nothing has been opened; the plan and the verification status live in the comment this note points to.

@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Split plan pointer. The six-unit split plan for the 2357 head — refs, boundaries, per-unit counted executable lines (448 / 88 / 131 / 27 / 25 / 51, summing to exactly 770), what has been verified alone, the verification disclosure about @roo-code resolution, and the merge-order constraint that #2350 imposes — now lives on #2350, in comment 6735171826, because #2350 is the defect about the interaction between the plan's u1 and u2 (writer staging spell versus recovery's leftover prefixes) and it constrains the order rather than merely hosting the list.

Nothing has been opened for this plan from #2357. The scope line for the current 2357 head d6fa7117e07b70455da24e4722b47eb0b202fa6e (770 counted executable lines against a 500 limit, 26 files, +6,995 −76) is stated in the superseding width note on #2357, which names the five older comments it replaces.

@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Already reviewed the last commit. Use @coderabbitai full review to rerun a review of the entire changeset.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

easonLiangWorldedtech added a commit to easonLiangWorldedtech/Zoo-Code that referenced this pull request Nov 10, 2026
…er (U5c-7, split of Zoo-Code-Org#1991)

ObservationRegistry records per-file observation state with a completeness flag,
refuses further recording once closed, and is dropped when a task is disposed.
It lands together with the Task-side code that imports it, because the repo's own
policy asserts that a production file imported only by tests is still reported as
unused (scripts/knip-policy.test.mjs) and knip --production is a required check.

384 additions / 0 deletions across 5 paths. The mutation gate counts 25 of 500
executable lines and 15 of 400 selectors at this base.
Content ported byte-identical from Zoo-Code-Org#2357 (5319fe9..d6fa711).
@github-actions github-actions Bot added the has-conflicts PR has merge conflicts with the base branch label Nov 11, 2026
easonLiangWorldedtech added a commit to easonLiangWorldedtech/Zoo-Code that referenced this pull request Nov 11, 2026
…er (U5c-7, split of Zoo-Code-Org#1991)

ObservationRegistry records per-file observation state with a completeness flag,
refuses further recording once closed, and is dropped when a task is disposed.
It lands together with the Task-side code that imports it, because the repo's own
policy asserts that a production file imported only by tests is still reported as
unused (scripts/knip-policy.test.mjs) and knip --production is a required check.

384 additions / 0 deletions across 5 paths. The mutation gate counts 25 of 500
executable lines and 15 of 400 selectors at this base.
Content ported byte-identical from Zoo-Code-Org#2357 (5319fe9..d6fa711).
…very rejects

readFileWithMissingRetry awaited recoverMissingJsonFile inside its retry catch but
outside any handler, so a rejection from the recovery escaped the function instead of
becoming the value that branch interprets. Every caller classifies this read by error
code: apiMessages.ts maps ENOENT to "no file yet" and anything else to io_error,
taskMessages.ts maps it to the "not_found" kind, TaskHistoryStore.ts swallows it into
null. A rejecting recovery therefore re-classifies the read: an EBUSY inside recovery
becomes an I/O error on a file that is merely absent. The fix keeps the missing-file
retry the rows already assert and counts a rejected recovery as "nothing recovered", so
the caller always receives the ENOENT read failure it knows how to classify.
jsonFileRecovery traces the recovery failure with its own code before throwing, so the
diagnostic is not lost, and no new abstraction is introduced.

safeWriteJson.ts:343 is a changed executable line in this pull's own diff: the hunk
switches its argument from absoluteFilePath to resolvedTargetPath, and no row
distinguished the two spellings because every existing recovery row keeps the alias and
the referent in one directory. The added row plants the orphaned backup beside the
referent with the alias directory empty, which only the resolved-target spelling can
see, and asserts that the merge callback read the restored history, that the trace
reported the restore, that the published file holds the merged content, and that the
backup was consumed. The link is simulated by mocking fs.realpath, the same way the
staging and lock-key rows in that file do, because real symlinks are unavailable here.

Controls, holding the previous head d6fa711:
- new retry row against the previous head with the source untouched: 1 failed | 3
  passed, the failure being the escaping EBUSY rejection; with the fix: 4 passed.
- the same row against each mutant the instrument plans for lines 54-55 of the fix:
  a recovery rejection counted as recovered (.catch(() => true)) makes the reader
  resolve with bytes that appeared during the recovery and fails this row alone;
  reverting line 49 to its pre-fix shape fails this row alone.
- new recovery row with 343 reverted to its pre-pull argument: the only failure in
  utils/__tests__/safeWriteJson.test.ts (1 failed | 53 passed | 4 skipped), so the row
  reaches 343 and asserts exactly the change this pull made there.
- repo-wide prettier --check .: clean. tsc --noEmit in src: clean. eslint on the three
  touched files with --max-warnings=0 --prune-suppressions: no problems, and
  src/eslint-suppressions.json is byte-identical to its committed blob (a prune run on
  a partial file set rewrites that file, so it was reverted).

Pricing, printed by scripts/stryker-diff.mjs (blob c332971815aecfaf) against this
pull's own base 5319fe9: the instrument reports
"extension has 770 changed executable lines (limit 500)" at the previous head and 772
here, so this commit adds two chargeable executable lines to a package already 270
lines over the cap, and 2357 stays a split question - this fix does not pretend
otherwise. On its own unit delta the instrument lists only
core/task-persistence/readFileWithMissingRetry.ts:54-55 (2 chargeable lines) and its
dry run instrumented that file with 5 mutants (limit 400): the two added rows are
test-only and the instrument's own path classifier excludes __tests__ paths, so the
rows are cap-neutral by construction rather than by headroom.

Review thread answered: pull-request comment 4505288199, on src/utils/safeWriteJson.ts
line 343. The await above has no review thread to answer: the thread listing read from
this repository tonight lists 50 threads, every one of them resolved, and none on
src/core/task-persistence/readFileWithMissingRetry.ts.

No changeset and no changelog entry.
@easonLiangWorldedtech

easonLiangWorldedtech commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor Author

This pull cannot be split into two units that clear both caps. The 500-line cap is clearable on
both halves of the only axis the import graph allows; the 400-mutant cap is not clearable on either
ordering of it. Prices below are produced the way CI produces them: on the synthetic merge commit
GitHub builds, first parent = base tip, by scripts/stryker-diff.mjs as committed on this branch
(blob c332971815aecfaf, MAX_CHANGED_LINES = 500, MAX_MUTANTS = 400), reading source out of the
commit tree rather than a working tree. Calibration: the same engine returns 772 counted executable
lines for refs/pull/2357/head against its merge base 5319fe91fb0c, which is the number CI printed
for this pull.

The axis is forced by the import graph, not chosen. Measured at 9c24106b13f4: of the ten
chargeable files, src/services/file-safety/safeWriteText.ts imports none of the others, while
src/utils/safeWriteJson.ts:6 reads
import { resolveLockKey, resolvePublishTarget } from "../services/file-safety/safeWriteText",
jsonFileRecovery.ts imports fileLock.ts, readFileWithMissingRetry.ts imports jsonFileRecovery.ts,
McpHub.ts imports safeWriteJson.ts, Task.ts imports McpHub.ts and observationRegistry.ts, and
ReadFileTool.ts imports Task.ts and indentation-reader.ts. So the writer goes first or it
compiles nowhere: the shape that ships everything except the writer measures 299 lines — inside the
line cap — and fails tsc --noEmit with 37 errors against the tip's own 22, five of them
error TS2307: Cannot find module '../safeWriteText' or its corresponding type declarations at
utils/safeWriteJson.ts:6, services/file-safety/__tests__/safeWriteText.spec.ts:21,
services/file-safety/__tests__/safeWriteText.integration.spec.ts:5,
utils/__tests__/fileLock.canonicalKey.spec.ts:8 and utils/__tests__/safeWriteJson.lockKey.spec.ts:9.

Four shapes, all against main efc22ed9f6f75975106f170cf29cc9bcc0b86682. None of them is pushed:
they are local synthetic merges, rebuildable with git merge-tree --write-tree plus
git commit-tree <tree> -p <base> -p <candidate>; trees are printed so every number here can be
re-derived without a server.

shape commit tree counted executable lines selector ranges instrumented mutants
parent: main tip x pull head, the two conflicts left as merge-tree writes them d2ab9d57570f0e1e056682ea272856d31c61adef b3b745e8c64120d78c134df9ef7bad6259eb486a 747 (over 500) 246 ranges line cap throws before the instrumenter runs
unit 1: writer only, every deferred path held at the tip 45e64ab5b6adee2731f6d926d437de5763d27513 769f34aabd8281889228089df120facfa2d9b8d6 448 (under 500) 130 425 (over 400)
unit 2: pull head merged over unit 1 (-X theirs, no unmerged paths) 218e3b8e2821d23dcd08e8af1e475f309f42e037 1537e49466ef824894af4ed884818ca98563c98d 299 (under 500) 116 362 (under 400)
reverse cut: everything except the writer c0cf68960287ecb7517fe351b801c8af07b6c01c 483838b53a8acbd50a99082bfaf4dfb44f512efb 299 (under 500) 116 not reached — 15 more typecheck errors than the baseline

The cut loses nothing and floats nothing: 448 + 299 = 747, the parent's own price, and 425 + 362 = 787,
which is the sum of the eight per-path instrumented counts (425, 144, 69, 82, 32, 12, 18, 5), so each
half's figure is attributable to the paths inside it. The same three line totals come out at
6313746170526419325022dbb161dde5960dd8b2 and at 53cb5b5e7c8014d3226e5636bbca8999ed3b8526:
main's two newest commits touch 22 files (2 then 20) and none of this pull's chargeable paths, so 747 / 448 / 299
are base-independent.

One path brings the pull under both caps: src/services/file-safety/safeWriteText.ts — 448 counted
executable lines, 130 selector ranges, 425 instrumented mutants.
Excluding that path leaves 299
lines, 116 selector ranges across 7 source files, and 362 instrumented mutants: safeWriteJson.ts
131 / 46 / 144, fileLock.ts 62 / 21 / 69, ReadFileTool.ts 40 / 25 / 82, McpHub.ts 27 / 14 / 32,
jsonFileRecovery.ts 26 / 3 / 12, indentation-reader.ts 11 / 6 / 18,
readFileWithMissingRetry.ts 2 / 1 / 5. No other single path can do it: 747 − 500 = 247 lines have to
leave and the largest chargeable path apart from this one is 131 lines, and any set that keeps this
file keeps the 425 mutants that are attached to it.

Every mutant figure above is an instrumented count, not a verdict. The local preflight cannot
complete on this host: line 421 of the instrument spawns node_modules/.bin/stryker, which on Windows
is a .cmd that spawnSync reports with status null, and past that Stryker's sandbox throws
Cannot find module 'vitest/config' because the install lives behind a junction the sandbox copy
loses. So there is no initial test run, no killed/survived/no-coverage counts, and no pass or fail —
425 means 425 mutants were generated for that file, and nothing more. The CI run is the only
place these numbers get a verdict.

Conflict against main is exactly two paths: src/core/task/Task.ts and
src/core/task/__tests__/Task.spec.ts. The pull's review state at the head it serves now
(9c24106b13f46f9d39fc3c4244eabc94435c1c61) reads CHANGES_REQUESTED, and this credential cannot
dismiss it: dismissPullRequestReview and deletePullRequestReview both return FORBIDDEN for it.

One question, and it is the reviewer's: do you grant a one-path exclusion for
src/services/file-safety/safeWriteText.ts and let this pull through at 299 lines / 362 instrumented
mutants — or do you want the writer split into its own pull, which carries 448 lines and 425
instrumented mutants and so is over the mutant cap standing on its own?

@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Reply to comment 6812390131 - the answer to the one question that comment put, carried by a

measurement taken now

The question, as that comment stated it: grant the one-path exclusion for
src/services/file-safety/safeWriteText.ts and let the pull through, or split the writer into its own
pull. The answer below is a measurement at the pull's own footing, with the legs that did not run
named beside every figure that does not rest on them.

Measured now, at the footing the gate prints

Instrument: scripts/stryker-diff.mjs taken from commit 9811691 by
git show, not from a working tree - 30,108 bytes, sha256-16 c332971815aecfaf. It declares
MAX_CHANGED_LINES 500 and MAX_MUTANTS 400 and excludes the path segments /mocks/, /tests/,
/fixtures/ and /test-utils/.

Pull 2357 at head 9c24106 against its own merge base
5319fe9: extension 772 changed executable lines across 10
chargeable files, which the instrument itself would print as 'extension has 772 changed executable
lines (limit 500)'.

The same head with src/services/file-safety/safeWriteText.ts (448 lines, 130 selector ranges)
excluded: 324 changed executable lines, under the 500-line cap. The largest remaining chargeable
paths are src/utils/safeWriteJson.ts 131 lines / 46 ranges, src/utils/fileLock.ts 62/21,
src/core/tools/ReadFileTool.ts 40/25, src/services/mcp/McpHub.ts 27/14 and
src/utils/jsonFileRecovery.ts 26/3.

Main's tip fetched today is efc22ed, a descendant of that merge
base, so this head prices the same at both footings: 772 whole and 324 with the writer excluded.
Runner artifact .tmp-cr-audit/_scratch/u130/prices-u130.json, 2,422 bytes, sha256-16
33a1917b5fe260d8. It reproduces the 772 figure this lane recorded as its calibration point, so the
runner is the instrument's arithmetic and not a new yardstick.

What those two numbers settle, and what they do not

The line relief is not portable: 772 minus 131 is 641, so removing the largest chargeable path other
than the writer still leaves the line cap violated. The writer is the only single path whose removal
brings this head under 500 lines, and it brings it to 324. That is the whole of what this
measurement says about the question as asked.

It is a line-cap measurement only. No instrumented mutant pass ran at this footing, so nothing here
states how many mutants this head generates.

The mutant figures that exist for this pull come from synthetic shapes rebased onto efc22ed and
they are generation counts, not verdicts: whole shape 747 lines and 246 selector ranges, writer
alone 448 lines and 425 generated mutants, residual 299 lines and 362 generated mutants, and the
per-path instrumented triple 425 + 144 + 69 + 82 + 32 + 12 + 18 + 5 = 787. Artifacts behind them:
.tmp-cr-audit/_scratch/u106/prices.json 11,570 bytes 2867be1c64b6bfb2, u106/per-path-mutants.json
1,686 bytes 6c93f0bae2a3e02d, u107/mutants-newbase.json 357 bytes c739c2f608161f90,
u108/verification.json 703 bytes 8c8d56281de80db6, u108/shapes.json 360 bytes 297c145e33f7e688,
u108/shape-identities.json 527 bytes 714a8af3ecb19181.

The two footings differ by 25 lines on both sides - 772 against 747 whole, 324 against 299 residual.
The composition of that 25 was not re-derived in this unit, so it is quoted as a measured gap and
not explained.

Legs that did not run, named beside the figures that do not rest on them

No instrumented mutant pass at this head's own footing: the 772 and 324 above are
changed-executable-line counts only. No initial test run, no killed counts, no survived counts and
therefore no verdict anywhere in this lane's local legs - instrument line 421 spawns
node_modules/.bin/stryker, a .cmd that spawnSync cannot run without a shell, and past that Stryker's
sandbox throws Cannot find module 'vitest/config' because the install sits behind a junction the
sandbox loses. No rebase, no merge and no conflict resolution: the pull's conflict against main
stands untouched. No Stryker disable directive, no change to any suppression file, no branch push
and no CI re-run.

Posture read on the free door at the same moment

GET /pulls/2357 (HTTP 200, 25,204 bytes, sha256-16 05cb576dc6aa36dc): state open, mergeable false,
mergeable_state dirty, labels awaiting-author and has-conflicts, head unchanged at 9c24106...,
base reported 5319fe9..., updated_at 2026-11-12T06:34:47Z, 80 commits, and GitHub's own raw
widths 7,088 additions, 77 deletions, 28 changed files - widths, not the instrument's priced lines.

GET /pulls/2357/reviews?per_page=100 (HTTP 200, 263,073 bytes, ed6ee31afbeda6b3): 100 rows on page
1, newest row on that page submitted 2026-11-09T01:10:42Z. Later pages were not read, so this
comment claims no newest review.

GET /issues/2357/comments?since=2026-11-12T02:15:04Z (HTTP 200, 7,663 bytes, b86d5fb2f1cb7a53): 1
item, the id of the comment being replied to, so as of that read nothing had been posted beneath it.

What would change the answer

A mutant count measured at this head's own footing, which needs a Stryker leg this host cannot
complete, and a rebased head for the instrument to price against. Both are named rather than
assumed. The measurement above is recorded in this lane's books as ledger section 289.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-author PR is waiting for the author to address requested changes has-conflicts PR has merge conflicts with the base branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant